京东PLUS会员项目 前端性能优化实践
来这里找志同道合的小伙伴!
京东PLUS会员项目是国内第一个电商付费会员项目,正式开通的会员数量已破千万。
这个项目有个特点:需求多。移动端使用 H5 开发,曾有人问为什么不用原生或者 RN 开发?以这个项目的需求数量和迭代速度来看,连 H5 都难以 hold 住,还是不要奢望原生和 RN 了。
用户众多和需求迭代频繁,确保线上安全稳定始终是第一要务。所以在架构调整和性能优化方面以一些小修补为主,只有到大规模改版的时候才会有大的升级改造。
前端开发的发展迭代速度很快,若等到这些优化方案全都应用上再出来念叨,可能就显得不那么新鲜了。所以,先把这些方案拿出来分享,和感兴趣的小伙伴一起讨论,进一步完善。
这些方案主要针对移动端,优化核心方向是提高首页的加载速度,特别是首屏和弱网络环境下的加载速度。从持久化缓存、削减代码量、优化接口请求、提升主观感受等方面下手,比较大的改动是应用 PWA
和升级架构。 PWA
离线缓存可以极大的提升用户体验,不过它对于首次加载速度并无提升作用,还得靠其他优化手段,这是一套组合拳。
先从架构升级说起吧。
项目计划迁移到 Gaea4.0
脚手架[1],这是JDC前端团队基于 webpack 4 开发的一套通用 Vue 单页面应用脚手架,此前的系列版本已经过数十个项目的验证,还是比较稳定的。
近期新推出4.0版相较之前版本有着不小的改进:
webpack 升级到了 4.0
Babel 升级到了 7.0
Vue-loader 升级到了 15
重构了上传插件,一键上传到测试服务器更快更稳定
针对我厂手机和电脑位于不同局域网无法互访的问题,集成了自主研发的 Carefree 解决方案[2],方便真机测试调试
集成了 NutUI 组件库[3],可按需加载需要的UI组件
集成了自主研发的基于swagger的数据mock工具SMOCK[4]
支持自动生成骨架屏[5]
支持 PWA
…
迁移有几个主要目的:
首先,实现本项目的 webpack 构建工具升级到 4.0,之前是基于 webpack 2.0 开发的,webpack4 有不少提升,比如:
Scope Hoisting(作用域提升,webpack3加入),通过减少闭包函数数量加快JS的执行速度
生产环境构建体积更小
开发环境通过优化的增量构建机制提升构建速度,同时提供详细的错误和提示
其次, Gaea4.0
的 Babel 是 7.0 版的,基于 Babel7 可以实现更智能的 Babel polyfill 按需加载。
再次,本次优化计划尝试的PWA、骨架屏等方案, Gaea4.0
都可以给予基础支持。
最后, Gaea4.0
集成的Carefree、新的上传插件等功能将给未来的开发和真机调试带来方便。
如今的 web 应用开发都是在本地进行构建,所以有条件在构建阶段把高版本的 JS 代码编译成低版本语法,这样既使用了新语法,又解决了低版本浏览器的兼容问题。承担这种转换工作的最知名的工具当属 Babel 了。但一直以来,Babel 有个饱受诟病的地方,那就是 polyfill 问题。
Babel 默认只转换 JavaScript 语法,而不转换新的 API,比如 Promise、Generator、Set、Maps、Symbol 等全局对象,一些定义在全局对象上的方法(比如 Object.assign)也不会被转码。如果想让未转码的 API 可在低版本环境正常运行,这就需要使用 polyfill。
polyfill 有多种方案,各有各的问题。目前应用中通常使用 babel-polyfill 方案,而第三方库中通常使用 babel-runtime 和 babel-plugin-transform-runtime 方案。
babel-polyfill 提供完整的环境垫片,包含所有 API 的降级模块,可以为新的 API 和全局对象上的方法提供兜底,其主要缺点是文件较大,压缩后大概8、90KB。目前项目中采用这种方案,这次考虑予以优化,减少加载的代码体积。
如上文提到,这一波改造会把项目迁移到 Gaea4.0
脚手架中,新脚手架的 Babel 已经升级到了最新的 7.0 版。Babel7 是 Babel6 推出近三年之后发布的一个断崖式升级的大版本,包含很多新特性,其中一个引人关注的特性就是支持更智能的按需加载 polyfill。
Babel7 主要是通过其提供的 @babel/preset-env
实现按需加载的。
使用 @babel/preset-env
也需要首先安装 @babel/polyfill
,但最终打出的包并不会导入全部 polyfill。
npm install @babel/polyfill --save
同时,需要在 .browserslistrc 文件或者 .babelrc 的 targets 字段中指定需要兼容的浏览器范围。
之后在.babelrc文件中对 @babel/preset-env
进行配置。
@babel/preset-env
与按需加载 polyfill 相关的选项是 useBuiltIns
,它有两个值需要重点关注: entry
和 usage
。
当值为 entry
时,Babel 会将 import"@babel/polyfill"
或者 require("@babel/polyfill")
语句根据指定的环境配置替换为单个的 polyfill require。
如将
import "@babel/polyfill";
替换为
import "core-js/modules/es7.string.pad-start";
import "core-js/modules/es7.string.pad-end";
当值为 usage
时,更加智能。Babel 会根据每个文件的需要和指定的环境配置添加特定的 polyfill,更牛的是一个 bundle 中相同的 polyfill 只会加载一次,这也有助于减小 bundle 的体积。推测 Babel 是通过对文件进行静态分析实现的这种精准的按需加载 polyfill 功能。
如
var a = new Promise();
转换后(如果指定的环境不支持)
import "core-js/modules/es6.promise";
var a = new Promise();
转换后(如果指定的环境支持)
var a = new Promise();
尝试先指定需要兼容的浏览器范围,然后安装 @babel/polyfill 并将 @babel/preset-env 的 useBuiltIns 选项值设为 usage。这样 Babel 就会自动分析每一个文件,并在考虑指浏览器兼容范围的情况下,为每个文件加载其需要的 polyfill。最终项目里只引入了部分 polyfill,经测算,打包后的代码(min)较直接引入完整 babel-polyfill 的方案小60多KB,同时还避免了全局变量污染。
在 Babel 的配置中开启 Debug 模式,构建的时候可以看到每个文件中添加了哪些 polyfill:
关于这个问题的进一步思考:
这种加载 polyfill 的方式已经比传统方式先进了很多,但还是不完美,比如按照指定的浏览器范围需要引入的某个 polyfill,对于高版本浏览器来说可能还是多余。
一种比较理想的方案是先在编译阶段通过静态分析确定可能需要 polyfill 的 API 范围但并不打包 polyfill 进去,而是当用户在浏览器中访问这个页面时,通过植入页面的JS脚本逐一检测当前浏览器是否支持这些新的 API,把不支持的找出来,通过一个请求去服务端加载对应的 polyfill 文件。当然这需要类似 polyfill.io
的服务端 polyfill 方案支持。未来会沿着这个方向继续探索。
PWA
的一系列功能中最重磅的非离线缓存莫属了,虽说 H5 之前就有离线缓存(application cache)API,可惜不好用。
从业务角度来讲,本项目不太适合离线访问,但可以利用 PWA
把静态资源进行离线缓存,提高页面访问速度。
在这种场景下,用 ServiceWorker
不缓存页面自身 HTML 和接口数据,只缓存静态资源,且优先使用缓存。非首次访问的情况下,静态资源都会走缓存,页面访问速度得以大幅提升。
但有一个问题,就是页面更新的问题。使用缓存优先策略,意味着每次进入页面时,在有缓存的情况下直接使用缓存。如果缓存有更新,在缓存更新之后需要刷新页面才能看到变化。自动刷新页面严重影响用户体验,而提示用户去手动刷新,在 APP 里看上去也有些奇怪,且不是所有有用户都会去手动刷新的。对于PLUS会员这种需求排队,更新频繁的项目,用户感受到的影响可能会更多。HTML5 的离线缓存 API 也有这个问题,这当然不是一个缺陷,而是“优先使用缓存”策略所决定的,只是不完全满足项目的需求罢了。
针对这个问题的解决方案是当文件有更新时,同时修改缓存的版本号和页面中引用这个文件的 URL 中的版本号,让浏览器直接使用新文件,不使用缓存。在页面加载之后,缓存也会更新,下次访问时,还会走缓存。
这个方案还有优化空间,只有那些有变化的文件需要更改 URL 中的版本号,使用新文件,而页面中其他没有发生变化的静态资源还是可以也应该继续使用缓存。
按照这个思路,应把代码中稳定的、不常变化的模块(比如 Vue 及其插件)尽量提取出来,让这部分内容尽可能使用缓存,当然必要的时候也可以通过相同的方式更新。而经常发生变化的部分(如业务代码)应独立打包,体积越小越好,以减小页面和缓存更新时的开销。
对于这些稳定公共模块的提取使用 webpack 内置的 DllPlugin
和 DllReferencePlugin
插件来实现,通过这两个插件提前对这些公共模块进行独立编译,打出一个 vendor.dll.js 的包,之后在这部分代码没有改动的情况下不再对它们进行编译,所以项目平时的构建速度也会提升不少。vendor.dll.js 包独立存在,hash 不会发生变化,特别适合持久化缓存。
于是,当业务代码有变化时,只需要以新版号发布业务包(app.js)即可,vendor.dll.js 依然使用本地缓存。
下面看一下具体的加载情况:
首次访问,没有 PWA
缓存,所有资源都走线上。页面加载之后,PWA会缓存静态资源。
之后的访问,静态资源优先从缓存加载,速度极快。
当业务代码有更新时,更改页面中引用 app.js 文件的 URL 中的版本号,使得 app.js 不使用缓存,已缓存的其他静态资源依然可以使用缓存。同时更改缓存的版本号,缓存也会在页面加载之后更新,新的 app.js 文件也会被缓存。
再次访问时,包括 app.js 在内的静态资源依然全部走缓存。
这个是一个前后端分离的项目,前端是标准的 Vue SPA,完全通过接口同后端进行数据交互。PLUS会员业务逻辑本身比较复杂,涉及很多种用户状态,页面逻辑也复杂。不同用户看到的界面不完全相同,这受用户状态和后台配置等多种因素影响。
部分接口存在相互依赖的关系,比如有接口要求传用户状态,因此需要先行通过用户信息接口拿到用户状态。再比如商品数据接口,需要先请求楼层配置信息接口,确定当前页面有哪些楼层,继而才能决定去请求哪些楼层的数据。
这种串行的接口请求拖慢了首屏的渲染,这是目前影响首页性能的一个主要问题,也是这次优化的一个重点。
服务端渲染(如Vue SSR),首屏直出当然是最理想的方案。但目前看来并不现实,这个项目的研发团队情况也比较复杂,前后端是两个跨职场、跨部门的团队,且需求巨多,页面改动频繁。完全的前后端分离更有助于明确职责,提高效率。
另一个折中的方案是,在页面上直接引一个后端的模板文件,后端研发同事通过这个模板文件把用户状态、楼层配置等前置信息打到页面上,页面在浏览器中初始化的时候直接读取这些信息,然后再去请求那些依赖这些数据的接口。这样即可避免串行请求的问题,同时还减少了几个请求,有助于提高页面加载和渲染速度。这次优化,计划采用这种方案。
优化前:
优化后,关键请求大幅提前:
优化前:
优化后,页面开始渲染的时间明显提前:
前后端分离是一种进步,但彻底的分离,也不尽善尽美,比如会有首屏加载速度和 SEO 方面的困扰。 前后端分离+服务端首屏渲染
看起来是个更优的方案,它结合了前后端分离和服务端渲染两者的优点,既做到了前后端分离,又能保证首页渲染速度,还有利于 SEO。但在 Vue、React 等前端框架大行其道的今天,服务端渲染早已不是当年套 HTML 页面那么简单了,即便只渲染个首屏。
前后端同构可能是比较好的解决方案,而这种场景下服务端渲染工作显然由前端来承担更合适,所以用 Node.js 搞个中间层是必要的。
通过一系列优化,除了客观上首屏渲染时间的明显缩短,还额外给页面加上了骨架屏(skeleton screen),让用户主观感受到的页面加载和渲染速度比真实情况还快。
先来了解一下骨架屏的概念。骨架屏指的是在页面数据加载完成前,先给用户展示出的页面大致结构,之后渲染出真实页面内容将其换掉。这是近两年流行起来的加载控件,本质上是界面加载过程中的过渡效果。
在加载完成前把网页的大概轮廓预先显示,接着逐渐加载真正内容,这样既可缓解用户等待的焦灼情绪,又能使界面的加载过程显得更自然通畅,减少了长时间白屏或者闪烁。骨架屏能给人一种页面内容“已经渲染出一部分”的感觉,相较于传统的 loading 效果,体验更佳。
JDC前端团队对骨架屏技术有比较深入的研究,开发过一个名为 @nutui/draw-page-structure
[4]的webpack插件,可实现通过 puppeteer 自动生成纯 DOM 形式的页面骨架屏,并支持自动插入到指定页面。如果对自动生成的效果不满意,还允许定制和调整。
用这个插件在项目里小试了一把,效果还是不错滴。纯 DOM 形式的骨架屏代码,比图片、Canvas等形式数据量更小,调整起来也更灵活。
PLUS会员频道首页是一个典型的电商页面,包含大量的图片。使用新兴的图片格式可以大大减少加载的图片体积,并有助于提升图片的解析和渲染速度,进而提升页面渲染速度。对于移动web来说,还有一个重要的优点——节省用户的流量。
之前在项目里应用了 WebP
格式,收效不错。比如某张背景图片,压缩后的 png 格式是35KB,而转成 WebP
只有4KB,两者基本看不出质量上的差别。
新兴图片格式的应用的主要障碍还是兼容性,以 WebP
为例,谷歌系的浏览器以及欧朋浏览器支持情况良好,Firefox、Edge 也都在新版本提供了支持,可惜苹果公司一直没有跟进,Safari 直到现在也没有要支持的迹象,iOS 上的应用如果想支持,还需自行打包解析库(经测试发现iOS版的京东APP已经提供了支持)。
使用 WebP
的方式是在页面上通过JS判断当前浏览器是否支持 WebP
,如果支持,则在 body 上增加一个名为 “webp” 的 class,同时把判断结果写入 localStorage,之后再进入页面时直接从 localStorage 里读取,不用每次都执行判断的代码了。然后在页面的 css 中通过 “.webp” 选择器、在 Vue 的图片过滤器中通过判断结果来决定是否加载 WebP
格式图片。
document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0
这次的优化,考虑增加对 DPG
图片格式的支持。
DPG 是京东基础架构部-智能存储部推出图片压缩技术,经过 DPG 压缩后的图片兼容 jpeg,同时全平台、全部浏览器都支持,DPG 是一种有损压缩技术,但通过5名用户10000张图片的人眼浏览测试,和 WebP 的清晰度对比没有差距。该技术可以有效地减少图片大小50%,减少 CDN 带宽流量 50%,加快图片用户在设备上的渲染速度。
DPG
格式是对 jpeg 格式图片通过一定算法进行了二次压缩,其本质上还是 jpeg(虽然扩展名改了),这也才能有所谓”全平台浏览器支持“的可能性。所以,特别适合将 jpeg 格式的图片替换为 DPG
格式,当然前提是服务器上有 DPG
格式图片。京东的图片系统会自动生成上传图片对应的 DPG
格式图片。所以 DPG
格式的使用条件就是原图是 jpeg 格式,且图片位于京东图片系统中。在兼顾既有的 WebP
格式图片加载逻辑的基础上,梳理后的图片加载逻辑如下图所示:
[1] https://www.npmjs.com/package/gaea-cli
[2] http://carefree.jd.com
[3] http://nutui.jd.com
[4] http://smock.jd.com
[5] https://www.npmjs.com/package/@nutui/draw-page-structure
推荐阅读